[Spring-plus] Spring Security를 이용한 로그인
🚷

[Spring-plus] Spring Security를 이용한 로그인

Lecture
Framework
태그
dev
spring
public
완성
N
생성일
Mar 17, 2024 01:40 PM
LectureName
Spring

1. SpringSecurity에서 로그인을 지원하는 방법

Spring security에서는 UserDEtailService 인터페이스를 구현하고 있는 클래스를 통해서 로그인 기능을 구현합니다.
 
UserDetailService를 통한 확장
서비스 부분에서 UserDetailService를 확장시키고 loadUserByUsername을 오버라이딩 한다.
package soti.shop.service; @Service @Transactional @RequiredArgsConstructor public class MemberService implements UserDetailsService { ~ 기존 코드 @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { Member member = memberRepository.findByEmail(email); if(member==null){ throw new UsernameNotFoundException(email); } //User 객체를 반환해준다. return User.builder() .username(member.getEmail()) .password(member.getPassword()) .roles(member.getRole().toString()) .build(); } }
 
 
SpringSecurity Config에서 로그인 정보를 설정해준다.
package soti.shop.config; @Configuration @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired MemberService memberService; @Override protected void configure(HttpSecurity http) throws Exception{ http.formLogin() .loginPage("/members/login") .defaultSuccessUrl("/") .usernameParameter("email") .failureUrl("/members/login/error") .and() .logout() .logoutRequestMatcher(new AntPathRequestMatcher("/members/logout")) .logoutSuccessUrl("/"); } @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(memberService).passwordEncoder(passwordEncoder()); } }
  • Spring Security에서 인증은 AuthenticationManager를 통해 이루어지며 AuthenticationManagerBuilder이 생성해 줍니다. userDetailService를 구현하고 있는 객체로 MemberService를 지정해주며, Password Encoder을 지정해 줄 수 있습니다.
 
 
로그인 폼 작성 (login.html)
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/layout1}"> <!-- 사용자 CSS 추가 --> <th:block layout:fragment="css"> <style> .error { color: #bd2130; } </style> </th:block> <div layout:fragment="content"> <form role="form" method="post" action="/members/login"> <div class="form-group"> <label th:for="email">이메일주소</label> <input type="email" name="email" class="form-control" placeholder="이메일을 입력해주세요"> </div> <div class="form-group"> <label th:for="password">비밀번호</label> <input type="password" name="password" id="password" class="form-control" placeholder="비밀번호 입력"> </div> <p th:if="${loginErrorMsg}" class="error" th:text="${loginErrorMsg}"></p> <button class="btn btn-primary">로그인</button> <button type="button" class="btn btn-primary" onClick="location.href='/members/new'">회원가입</button> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> </form> </div> </html>
 
LoginController 설정 (MemberController)
package soti.shop.controller; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import soti.shop.dto.MemberFormDto; import soti.shop.entity.Member; import soti.shop.service.MemberService; import javax.validation.Valid; @RequestMapping("/members") @Controller @RequiredArgsConstructor @Slf4j public class MemberController { ~ 이전코드 //login @GetMapping(value = "/login") public String loginMember(){ return "member/memberLoginForm"; } @GetMapping(value = "/login/error") public String loginError(Model model){ model.addAttribute("loginErrorMsg", "아이디 또는 비밀번호를 확인해주세요"); return "member/memberLoginForm"; } }
 
 

2. 테스트 코드 작성

Spring Security test 의존성 추가
//Spring-Security-Test implementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test'
 
 
로그인 테스트 코드 작성
package soti.shop.controller; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers; import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; import soti.shop.dto.MemberFormDto; import soti.shop.entity.Member; import soti.shop.repository.MemberRepository; import soti.shop.service.MemberService; import static org.junit.jupiter.api.Assertions.*; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; @SpringBootTest @AutoConfigureMockMvc @Transactional @TestPropertySource(locations = "classpath:application-test.properties") class MemberControllerTest { @Autowired private MemberService memberService; @Autowired private MockMvc mockMvc; @Autowired private PasswordEncoder passwordEncoder; public Member createMember(String email, String password){ MemberFormDto memberFromDto = new MemberFormDto(); memberFromDto.setEmail(email); memberFromDto.setPassword(password); memberFromDto.setName("jalnik"); memberFromDto.setAddress("광명시 하안동"); Member member = Member.createMember(memberFromDto, passwordEncoder); return memberService.saveMember(member); } @Test @DisplayName("로그인 성공 테스트") void loginMember() throws Exception { String email = "test@naver.com"; String password = "12341234"; this.createMember(email,password); mockMvc.perform(formLogin().userParameter("email") .loginProcessingUrl("/members/login") .user(email).password(password)) .andExpect(SecurityMockMvcResultMatchers.authenticated()); } @Test @DisplayName("로그인 실패 테스트") void loginError() throws Exception { String email = "test@naver.com"; String password = "12341234"; this.createMember(email,password); mockMvc.perform(formLogin().userParameter("email") .loginProcessingUrl("/members/login") .user(email).password("12341241241")) // 에러발생 .andExpect(SecurityMockMvcResultMatchers.unauthenticated()); } }
  • MockMvc는 테스트에만 사용할 수 있는 객체를 사용할 수 있습니다.
  • 해당 객체를 이용하면 웹 브라우저와 유사한 요청을 할 수 있습니다.
  • userParameter()을 이용하면 이메일을 아이디로 세팅하고 로그인 URL에 요청합니다.
 
 

3. 로그인, 로그아웃 표시하기

헤더에서 로그인, 로그아웃 표시하기
thymeleaf에서는 spring-security와 연동하여 로그인, 로그아웃 시 표시할 요소들을 지정할 수 있습니다.
 
  1. 의존성 추가하기
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5' // Thymeleaf Spring Security 통합 의존성
 
  1. /fragment/headers.html 수정하기
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"> <div th:fragment="header"> <nav class="navbar navbar-expand-sm bg-primary navbar-dark"> <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarTogglerDemo03" aria-controls="navbarTogglerDemo03" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <a class="navbar-brand" href="/">Shop</a> <div class="collapse navbar-collapse" id="navbarTogglerDemo03"> <ul class="navbar-nav mr-auto mt-2 mt-lg-0"> <li class="nav-item" sec:authorize="hasAnyAuthority('ROLE_ADMIN')"> <a class="nav-link" href="/admin/item/new">상품 등록</a> </li> <li class="nav-item" sec:authorize="hasAnyAuthority('ROLE_ADMIN')"> <a class="nav-link" href="/admin/items">상품 관리</a> </li> <li class="nav-item" sec:authorize="isAuthenticated()"> <a class="nav-link" href="/cart">장바구니</a> </li> <li class="nav-item" sec:authorize="isAuthenticated()"> <a class="nav-link" href="/orders">구매이력</a> </li> <li class="nav-item" sec:authorize="isAnonymous()"> <a class="nav-link" href="/members/login">로그인</a> </li> <li class="nav-item" sec:authorize="isAuthenticated()"> <a class="nav-link" href="/members/logout">로그아웃</a> </li> </ul> <form class="form-inline my-2 my-lg-0" th:action="@{/}" method="get"> <input name="searchQuery" class="form-control mr-sm-2" type="search" placeholder="Search" aria-label="Search"> <button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button> </form> </div> </nav> </div> </html>
 
 

4. 인가 페이지 구성하기

관리자가 접근 가능한 아이템 생성 페이지 만들기
  1. itemForm.html
<!Doctype html> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/layout1}"> <div layout:fragment="content"></div> <h1>상품 등록 페이지</h1> </html>
 
  1. Controller
package soti.shop.controller; import org.springframework.web.bind.annotation.GetMapping; @Controller public class ItemController { @GetMapping(value = "/admin/item/new") public String itemForm(){ return "/item/itemForm"; } }
 
ajax 요청 설정하기
package soti.shop.config; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { if ("XMLHttpRequest".equals(request.getHeader("x-requested-with"))) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); } else{ response.sendRedirect("/members/login"); } } }
  • 일반적인 접근 이외에도 ajax등 요청이 올 수 있다. 만약 해당 사항의 경우 헤더값에서 값을 읽어서 만약 인증정보가 없다면 401을 반환하고, 나머지는 로그인 페이지로 리 다이렉팅한다.
 
인가 페이지 설정
spring security configure 메서드에 HttpServletRequest를 처리할 옵션을 적는다.
@Override protected void configure(HttpSecurity http) throws Exception{ http.formLogin() .loginPage("/members/login") .defaultSuccessUrl("/") .usernameParameter("email") .failureUrl("/members/login/error") .and() .logout() .logoutRequestMatcher(new AntPathRequestMatcher("/members/logout")) .logoutSuccessUrl("/"); http.authorizeHttpRequests() .mvcMatchers("/", "/members/**", "/item/**", "/images/**").permitAll() .mvcMatchers("/admin/**").hasRole("ADMIN").anyRequest().authenticated(); http.exceptionHandling() .authenticationEntryPoint(new CustomAuthenticationEntryPoint()); //인증되지 않은 사용자 접근 시 핸들러 수행 }
  • authroizeHttpRequest() 는 HttpServletRequest로 요청을 처리할 것을 명령하고
  • permitAll() 앞에 붙어있는 경로는 접근을 허용한다는 것이다.,
  • authenticated() 앞에 붙어 있는 경로는 인증이 필요한 것이다.(Admin)
 
대신 static 경로에 있는 디렉터리 하위 파일은 인증을 무시하도록 설정하자
@Override public void configure(WebSecurity web) throws Exception{ web.ignoring().antMatchers("/css/**", "/js/**", "/img/**"); }
 
 

5. 인가 페이지 테스트

package soti.shop.controller; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc @TestPropertySource(locations="classpath:application-test.properties") class ItemControllerTest { @Autowired MockMvc mockMvc; @Test @DisplayName("상품 등록 페이지 권한 테스트") @WithMockUser(username = "admin", roles = "ADMIN") public void itemFormTest() throws Exception{ mockMvc.perform(MockMvcRequestBuilders.get("/admin/item/new")) .andDo(print()) .andExpect(status().isOk()); } @Test @DisplayName("상품 등록 페이지 일반 회원 접근 테스트") @WithMockUser(username = "user", roles = "USER") public void itemFormNotAdminTest() throws Exception{ mockMvc.perform(MockMvcRequestBuilders.get("/admin/item/new")) .andDo(print()) .andExpect(status().isForbidden()); } }
  • mockMvc에서는 @withMockUser을 사용하면 아이디가 admin이고 권한이 Admin인 유저로 접근할 수 있도록 합니다.
  • 해당 페이지에 get요청을 보내고 응답값을 받아서 테스트 합니다.